<?php
/**
 * OpenID 2.0 Authentication for PHP
 *
 * PHP version 5.1.2
 *
 * LICENSE:
 *
 * Copyright (c) 2006-2007 Pádraic Brady <padraic.brady@yahoo.com>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 *    * Redistributions of source code must retain the above copyright
 *      notice, this list of conditions and the following disclaimer.
 *    * Redistributions in binary form must reproduce the above copyright
 *      notice, this list of conditions and the following disclaimer in the
 *      documentation and/or other materials provided with the distribution.
 *    * The name of the author may not be used to endorse or promote products
 *      derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * @category Authentication
 * @package  OpenID
 * @author   Pádraic Brady <padraic.brady@yahoo.com>
 * @license  http://opensource.org/licenses/bsd-license.php New BSD License
 * @link     http://pearopenid.googlecode.com/
 */

/** Services_Yadis */
require_once 'Services/Yadis.php';

/** OpenID_Request_Association */
require_once 'OpenID/Request/Association.php';

/** OpenID_DiscoveryHtml */
require_once 'OpenID/DiscoveryHtml.php';

/** Crypt_HMAC2 */
require_once 'Crypt/HMAC2.php';

/** OpenID_Exception */
require_once 'OpenID/Exception.php';

/**
 * Retrieves data of an Association with an OP, i.e. where the Consumer has
 * established a shared secret key which will be used by the OP to sign
 * responses with a message authentication code.
 *
 * @category Authentication
 * @package  OpenID
 * @author   Pádraic Brady <padraic.brady@yahoo.com>
 * @license  http://opensource.org/licenses/bsd-license.php New BSD License
 * @link     http://pearopenid.googlecode.com/
 */
class OpenID_Association
{
    /**
     *  Regex for determining a valid canonical XRI identifier
     */
    const XRI_REGEX = "/^!=[A-Z0-9]{4}\.[A-Z0-9]{4}\.[A-Z0-9]{4}\.[A-Z0-9]{4}$/";

    /**
     * The type of Identifier a user appears to be using; one of the values
     * defined as constants ASSOCIATION_URI or ASSOCIATION_XRI.
     *
     * @var string
     */
    protected $identifierType = null;

    /**
     * The Consumer object for which Association data is being requested
     *
     * @var OpenID_Consumer
     */
    protected $consumer = null;

    /**
     * A store object which is used to both retrieve and write cached
     * Association data.
     *
     * @var OpenID_Store_Interface
     */
    protected $store = null;

    /**
     * During Yadis Discovery (OpenID 2.0) the initial request may return the
     * HTML retrieved from an OpenID alias URI. This can be cached and reused
     * for HTML based Discovery should the Yadis protocol fail, saving at
     * least one round-trip.
     *
     * @var string
     */
    protected $userHtml = null;

    /**
     * Array of all characters an XRI identifier can start with.
     *
     * @var array
     */
    protected $xriIdentifiers = array('=', '$', '!', '@', '+');

    /**
     * Constants:
     * The Identifier Type detected
     */
    const ASSOCIATION_URI = 'uri';
    const ASSOCIATION_XRI = 'xri';

    /**
     * Constructor. Creates a new Association object with a reference to the
     * Consumer object which requires the Association data
     *
     * @param OpenID_Consumer $consumer Instance of OpenID_Consumer
     *
     * @return void
     */
    public function __construct(OpenID_Consumer $consumer)
    {
        $this->consumer       = $consumer;
        $this->store          = $consumer->getStore();
        $userIdentifier       = $this->consumer->getUserIdentifier();
        $this->identifierType = $this->getIdentifierType($userIdentifier);
    }

    /**
     * Gets Association data. This can be fetched from a valid cache whose
     * Association data has not yet expired, or otherwise by sending
     * a new Association request to the discovered OP server.
     *
     * @return OpenID_KVContainer
     */
    public function getAssociation()
    {
        $opData = $this->discover($this->consumer->getUserIdentifier());

        // Reset the claimed identifier to the OP Local Identifier if needed
        if (isset($opData['localId']) && !empty($opData['localId'])) {
            $this->consumer->setClaimedIdentifier($opData['localId']);
        } else {
            $this->consumer->setClaimedIdentifier($this->consumer
                                                       ->getUserIdentifier());
            $this->consumer->setClaimedIdentifier($this->consumer
                                                       ->getUserIdentifier());
        }

        $this->consumer->setOpEndpoint($opData['opEndpoint']);
        if (isset($opData['serviceTypes']) && !empty($opData['serviceTypes'])) {
            $this->consumer->setServiceTypes($opData['serviceTypes']);
        }

        if ($this->hasCachedAssociation($this->consumer->getOpEndpoint())) {
            $cache = $this->getCachedAssociation($this->consumer->getOpEndpoint());
            if ($cache->expires > time()) {
                return $cache;
            }
        } elseif ($this->hasCachedAssociation($this->consumer
                                                   ->getClaimedIdentifier())) {
            $cache = $this->getCachedAssociation($this->consumer
                                                      ->getClaimedIdentifier());
            // inform subsequent steps that we should operate to a 
            // different assoc_type
            if ($cache->assoc_type !== OpenID::getAssocType()) {
                $this->resetAssocTypeTo($cache->assoc_type);
            }
            if ($cache->expires > time()) {
                return $cache;
            }
        }

        // Previous cache was stale or did not exist.
        $data = $this->renewAssociation();
        if (isset($data->error)) {
            return $data; // no caching of errors
        }

        $this->setCachedAssociation($this->consumer->getOpEndpoint(),
                                    $data->toString(),
                                    $this->consumer->getClaimedIdentifier());

        return $data;
    }

    /**
     * Associate with an OP server and retrieve valid association data. The data
     * may be cached based on the OP determined expires_in value (seconds).
     *
     * @return OpenID_KVContainer
     */
    public function associate()
    {
        $associationRequest  = new OpenID_Request_Association($this->consumer);
        $associationResponse = $associationRequest->request();

        $data = $associationResponse->getKV();

        if ($associationResponse->isError()) {
            return $data;
        } elseif (isset($data->mac_key)) {
            // passing the MAC in the clear means the response assumes
            // we're operating without encryption support
            $this->consumer->useEncryption(false);
        }

        $cache = new OpenID_KVContainer;
        if ($this->consumer->getEncryption() === true) {

            $sharedSecret = $this->getSharedSecret($data,
                                                   $associationRequest
                                                   ->getDiffieHellman());

            $cache->sharedSecret = base64_encode($sharedSecret);
            $cache->session_type = $data->session_type;
        } else {
            $cache->mac_key = $data->mac_key;
        }

        $cache->assoc_handle = $data->assoc_handle;
        $cache->assoc_type   = $data->assoc_type;
        $cache->version      = OpenID::getVersion();
        $cache->expires      = $data->expires_in + time() - 30;

        return $cache;
    }

    /**
     * Discovers the OP server by following the User's identity URI (or
     * alternately passing their XRI identity to an XRI Proxy service)
     * and parsing either the resulting Yadis XRD document, or HTML
     * page.
     *
     * One of the above should provide the URI to which OP server
     * requests may be sent.
     *
     * @param string $userIdentifier User Identifier
     * 
     * @throws OpenID_Exception on failure
     * @return array
     */
    public function discover($userIdentifier)
    {
        $result = $this->discoverYadis($userIdentifier);
        if ($result === false) {
            $result = $this->discoverHtml($userIdentifier);
        }
        if ($result === false) {
            throw new OpenID_Exception(
                'Unable to discover an OpenID service for this Identifier.' .
                ' Service discovery failed.'
            );
        }
        return $result;
    }

    /**
     * Perform discovery for OpenID 2.0 using the Yadis 1.0 Specification
     * protocol to request the User's identifier URI. If the User's
     * identifier is an XRI, request the XRI using the XDI proxy URI.
     * Return details of the Services discovered and the OP Server URI to
     * which association and authentication request should be directed.
     *
     * @param string $userIdentifier User Identifier
     *
     * @return array
     */
    public function discoverYadis($userIdentifier)
    {
        $yadis = new Services_Yadis($userIdentifier);
        $this->setUserHtml($yadis);
        $yadis->addNamespace('openid', OpenID::OPENID_XML_NAMESPACE);
        $serviceList = $yadis->discover();
        $this->setUserHtml($yadis);
        if (!$serviceList->valid()) {
            return false;
        }

        foreach ($serviceList as $service) {
            $types = $service->getTypes();

            $serverService = ($types == OpenID::OPENID_2_0_SERVICE_SERVER_TYPE
                              || in_array(OpenID::OPENID_2_0_SERVICE_SERVER_TYPE,
                                          $types));
            $signonService = ($types == OpenID::OPENID_2_0_SERVICE_SIGNON_TYPE
                              || in_array(OpenID::OPENID_2_0_SERVICE_SIGNON_TYPE,
                                          $types));
            if ($serverService === true || $signonService === true) {
                $OpService = $service;
                break 1;
            }
        }

        if (!isset($OpService)) {
            return false;
        }

        if ($this->identifierType == self::ASSOCIATION_URI) {
             // endpoints need to be prioritised
            $localId    = $OpService->getElements('xrd:LocalID');
            $opEndpoint = $OpService->getUris();

            if (isset($localId)
                && !Validate::uri($localId[0])
                || !Validate::uri($opEndpoint[0])) {

                return false;
            }

            $return = array();

            if (isset($localId)) {
                $return['localId'] = (string) $localId[0];
            }

            $return['opEndpoint']   = (string) $opEndpoint[0];
            $return['serviceTypes'] = $types;

            return $return;

        } elseif ($this->identifierType == self::ASSOCIATION_XRI) {
            return false;
            // testing to be performed
            $canonicalId = $serviceList->getXmlObject->CanonicalID;
            $providerId  = $serviceList->getXmlObject->ProviderID;
            if (!$this->isCanonicalId($canonicalId)
                || !$this->isProviderId($providerId)) {

                return false;
            }
            // set as claimed identifier
            exit('Not Implemented');
        }
        return false;
    }

    /**
     * Incomplete; Perform discovery for OpenID 1.1, or OpenID 2.0 (if Yadis
     * protocol discovery failed). Return details of the Services discovered
     * and the OP Server URI to which association and authentication requests
     * should be directed.
     *
     * @param string $userIdentifier User Identifier
     *
     * @todo Needs completion!
     * @return array
     */
    public function discoverHtml($userIdentifier)
    {
        $html      = new OpenID_DiscoveryHtml();
        $yadisHtml = $this->getUserHtml();

        if (empty($yadisHtml)) {
            return $html->discover($userIdentifier);
        } else {
            return $html->parse($yadisHtml);
        }
    }

    /**
     * Based on an Association response and the current Diffie-Hellman session,
     * compute the shared secret established with the OP Server during
     * association.
     *
     * @param OpenID_KVContainer  $response      Association Response
     * @param Crypt_DiffieHellman $diffieHellman Diffie-Hellman session
     *
     * @return string
     */
    public function getSharedSecret(OpenID_KVContainer $response,
                                    Crypt_DiffieHellman $diffieHellman)
    {
        $hashAlgorithm    = $this->getHash($response->assoc_type);
        $enc_mac_key      = base64_decode($response->enc_mac_key);
        $dh_server_public = base64_decode($response->dh_server_public);

        $diffieHellman->computeSecretKey($dh_server_public,
                                         Crypt_DiffieHellman::BINARY);
        $dh_shared_secret = $diffieHellman
                            ->getSharedSecretKey(Crypt_DiffieHellman::BTWOC);

        $dh_shared_secrethash = $this->hash($hashAlgorithm, $dh_shared_secret);

        $shared_secret      = '';
        $numberEncodedBytes = strlen(bin2hex($enc_mac_key)) / 2;

        for ($i = 0; $i < $numberEncodedBytes; $i++) {
            $shared_secret .= chr(ord($enc_mac_key[$i])
                                  ^ ord($dh_shared_secrethash[$i]));
        }
        return $shared_secret;
    }

    /**
     * Get the type of identifier (URI or XRI) that the Identifier's format
     * matches.
     *
     * @param string $identifier Identifier
     *
     * @return string
     */
    public function getIdentifierType($identifier)
    {
        if (Validate::uri($identifier)) {
            $identifierType = self::ASSOCIATION_URI;
        } elseif ($this->isXri($identifier)) {
            $identifierType = self::ASSOCIATION_XRI;
        } else {
            throw new OpenID_Exception(
                'Discovery may only be performed on a valid URI or XRI Identifier.'
            );
        }
        return $identifierType;
    }

    /**
     * In the event the Association Response is an error, we may be able to
     * recover by altering our required Association Type if an alternate
     * had been suggested by the OP in their response
     *
     * @throws OpenID_Exception
     * @return OpenID_KVContainer
     */
    protected function renewAssociation()
    {
        static $renewed = false; // limit recursion

        $data = $this->associate();
        if (isset($data->error)
            && $data->error_code == 'unsupported-type' && $renewed === false) {

            if (isset($data->assoc_type)) {
                $this->resetAssocTypeTo($data->assoc_type);
                $renewed = true;
                $data    = $this->renewAssociation();
            } elseif (isset($data->session_type)) {
                $this->resetAssocTypeTo($data->session_type);
                $renewed = true;
                $data    = $this->renewAssociation();
            } else {
                // may need to revert to (ugh!) stateless unencrypted mode here
                throw new OpenID_Exception(
                    'Provider returns an "unsupported-type" response but did not ' .
                    'provide an alternate supported method of association'
                );
            }
        }
        return $data;
    }

    /**
     * Check for the existence of a cached association
     *
     * @param string $uri Identifier URI
     *
     * @return bool
     */
    protected function hasCachedAssociation($uri)
    {
        return $this->store->hasAssociation($uri);
    }

    /**
     * Check for the existence of a cached association
     *
     * @param string $uri Identifier
     *
     * @return bool
     */
    protected function getCachedAssociation($uri)
    {
        $dataString = $this->store->getAssociation($uri);
        return new OpenID_KVContainer($dataString);
    }

    /**
     * Check for the existence of a cached association
     *
     * @param string $uri   Identifer
     * @param mixed  $data  Data
     * @param string $alias Alias (optional)
     *
     * @return bool true on success, false on failure
     */
    protected function setCachedAssociation($uri, $data, $alias = null)
    {
        return $this->store->setAssociation($uri, $data, $alias);
    }

    /**
     * Establish whether a claimed identifier is an XRI
     *
     * @param string $identifier Claimed Identifier
     *
     * @return bool true on success, false on failure
     */
    protected function isXri($identifier)
    {
        if (strpos($identifier, 'xri://') === 0 || in_array($identifier[0],
                                                            $this->xriIdentifiers)) {
            return true;
        }
        return false;
    }

    /**
     * Establish whether a claimed identifier is an XRI Canonical ID
     *
     * @param string $canonicalId Canonical ID
     *
     * @todo Some servers will prefix this with the "xri://" protocol
     * @return bool
     */
    protected function isCanonicalId($canonicalId)
    {
        return (bool) preg_match(self::XRI_REGEX, $canonicalId);
    }

    /**
     * Incomplete; Check whether the validity of the optional Provider URI
     *
     * @param string $providerId Provider ID
     *
     * @todo Incomplete, just returns true
     * @return bool true only
     */
    protected function isProviderId($providerId)
    {
        return true;
    }

    /**
     * Set the HTML cache of a Yadis protocol request to an OpenID user
     * identifier
     *
     * @param Service_Yadis $yadis Services_Yadis instance
     *
     * @return void
     */
    protected function setUserHtml(Services_Yadis $yadis)
    {
        $this->userHtml = $yadis->getUserResponse();
    }

    /**
     * Return the HTML cached from Yadis Service Discovery
     *
     * @return string
     */
    protected function getUserHtml()
    {
        return $this->userHtml;
    }

    /**
     * Simple hash digest support returning raw binary
     *
     * @param string $hash  Hash algorithm
     * @param string $value Data to be hashed
     *
     * @return string
     */
    protected function hash($hash, $value)
    {
        return hash($hash, $value, true);
    }

    /**
     * Get a hash type for the current process
     *
     * @param string $assocType Association Type
     *
     * @return string
     */
    protected function getHash($assocType)
    {
        if (OpenID::getVersion() == '2.0') {
            $hashAlgorithm = OpenID::OPENID_2_0_HASH_ALGORITHM;
        } else {
            $hashAlgorithm = OpenID::OPENID_1_1_HASH_ALGORITHM;
        }
        if ($hashAlgorithm !== OpenID::OPENID_1_1_HASH_ALGORITHM
            && preg_match('/' . OpenID::OPENID_1_1_HASH_ALGORITHM . '$/i',
                          $assocType)) {

            // in reality this means the server decided they can't support SHA256
            // and we may need to re-associate with the SHA1 type.
            $hashAlgorithm = OpenID::OPENID_1_1_HASH_ALGORITHM;
        }
        return $hashAlgorithm;
    }

    /**
     * Reset association and session types based on the association
     * response from the Provider
     *
     * @param string $type Association Type
     *
     * @todo Can't remember whether Server can force a no-encryption mode?
     * @return void
     */
    protected function resetAssocTypeTo($type)
    {
        if (preg_match("/SHA256$/i", $type)) {
            OpenID::setAssocType('SHA256');
            OpenID::setSessionType('SHA256');
        } elseif (preg_match("/SHA1$/i", $type)) {
            OpenID::setAssocType('SHA1');
            OpenID::setSessionType('SHA1');
        } else {
            throw new OpenID_Exception('Invalid association type: ', $type);
        }
    }

}

?>
